Skip to content

Note

Click here to download the full example code

domain change tables

two categorical models of the same blocks differ block by block: an old and a new domain model, the most likely category and a realization, or a model before and after cleanup. domain_change cross-tabulates them: the tonnage moving from each class of the first model to each class of the second and, given a grade, the metal and mean grade of every cell. rows sum to the first model's classes, columns to the second's, the diagonal holds what stays, and the metal adds up to the model's. bt.plot.domain_change draws the table as a matrix.

import sys
import warnings
from pathlib import Path

HERE = Path(__file__).parent if "__file__" in globals() else Path.cwd()
sys.path.insert(0, str(HERE.parents[1]))
warnings.filterwarnings("ignore", ".*locations hold several samples")
import boitata as bt
import matplotlib.pyplot as plt
import numpy as np
from common import save

two rock models

the section of domain cleanup across the stacked sulphide lenses: the most likely rock from categorical indicator kriging of the logged rock types on 10 m cells, and the same model after remove_small_units gives units under 5000 m³ to the rock around them. ordinary kriging estimates zinc and density on the same cells from the 5 m composites, ignoring the rock types.

data = bt.datasets.stacked_sulphide_lenses()
intervals = bt.merge_intervals(data["assays"], data["lithology"])
drillholes = bt.Drillholes(data["collars"], data["surveys"], intervals)
composites = drillholes.composite(5.0, ["ZN_PCT", "DENSITY"], categories=["LITH"])
scheme = bt.Categories(
    ["OB", "HWS", "VCL", "SUL", "FWV"],
    mapping={"MS": "SUL", "SMS": "SUL", "STR": "SUL"},
    other="DYK",
    colors=["#d9d9d9", "#a9bfd3", "#8c8c8c", "#c05a28", "#1f4e79", "#e0c080"],
)
weights = bt.cell_declustering(composites, scheme.encode(composites["LITH"]), cell_size=50.0).weights
layers = (23.0, 55.0, 0.0)
variograms = [
    bt.Variogram([("spherical", 0.9, 400.0)], nugget=0.1, rotation=(0.0, 0.0, 0.0), ratios=(1.0, 0.05)),
    bt.Variogram([("spherical", 0.9, 500.0)], nugget=0.1, rotation=layers, ratios=(0.8, 0.2)),
    bt.Variogram([("spherical", 0.85, 400.0)], nugget=0.15, rotation=layers, ratios=(0.8, 0.2)),
    bt.Variogram([("spherical", 0.8, 150.0)], nugget=0.2, rotation=layers, ratios=(0.8, 0.25)),
    bt.Variogram([("spherical", 0.9, 500.0)], nugget=0.1, rotation=layers, ratios=(0.8, 0.2)),
    bt.Variogram([("spherical", 0.7, 40.0)], nugget=0.3),
]
passes = [
    bt.Search(150.0, max_samples=24, max_per_hole=6, rotation=layers, ratios=(1.0, 0.4)),
    bt.Search(300.0, max_samples=24, rotation=layers, ratios=(1.0, 0.4)),
]
cik = bt.CategoricalIndicatorKriging(variograms, passes, scheme=scheme)
cik.fit(composites, "LITH", weights=weights, holes="HOLE_ID")

center = np.array([12350.0, 29900.0, 0.0])
across = np.array([np.sin(np.radians(113.0)), np.cos(np.radians(113.0)), 0.0])
along = np.array([np.sin(np.radians(23.0)), np.cos(np.radians(23.0)), 0.0])
origin = center - 600.0 * across - 5.0 * along + [0.0, 0.0, -400.0]
section = bt.BlockModel(origin, (10.0, 10.0, 10.0), (120, 1, 80), rotation=(23.0, 0.0, 0.0))
topography = data["topography"]
section = section.filter(section.z < topography.sample(section.coords[:, :2], "Z"))
section = section.with_columns({"rock": cik.predict(section).most_likely}).drop_null("rock")

grade = bt.Variogram([("spherical", 0.8, 200.0)], nugget=0.2, rotation=layers, ratios=(0.8, 0.2))
search = bt.Search(300.0, max_samples=24, max_per_hole=6, rotation=layers, ratios=(1.0, 0.4))
for column in ["ZN_PCT", "DENSITY"]:
    known = composites.drop_null(column)
    kriged = bt.OrdinaryKriging(grade, search).fit(known, column, holes="HOLE_ID").predict(section)
    section = section.with_columns({column: kriged})
section = section.drop_null("ZN_PCT", "DENSITY")
section = section.with_columns({"clean": bt.remove_small_units(section, "rock", min_volume=5000.0)})
print(f"{len(section)} cells; {np.sum(section['rock'] != section['clean'])} change rock")

Out:

5205 cells; 37 change rock

the table

with the block volumes as weights and the kriged density, tonnage is in tonnes; with the zinc grades each cell also holds its metal (tonnes × %) and mean_grade. the table has one row per pair of rocks, in the order of the scheme. the rows of a rock sum to its tonnage before the cleanup, its column to its tonnage after, and the metal to the metal of the model: the cleanup moves tonnes and metal between rocks and creates or loses none.

table = bt.domain_change("rock", "clean", density="DENSITY", grades="ZN_PCT", scheme=scheme, data=section)
k = len(scheme)
tonnes = table["tonnage"].reshape(k, k)
block = section.volumes * section["DENSITY"]
before = [block[section["rock"] == c].sum() for c in range(k)]
after = [block[section["clean"] == c].sum() for c in range(k)]
print(f"rows are the rocks before: {np.allclose(tonnes.sum(axis=1), before)}")
print(f"columns are the rocks after: {np.allclose(tonnes.sum(axis=0), after)}")
print(f"unchanged: {np.trace(tonnes) / tonnes.sum():.1%} of {tonnes.sum() / 1e6:.1f} Mt")
zinc = np.sum(block * section["ZN_PCT"]) / 100
print(f"zinc in the table {np.sum(table['metal']) / 100 / 1e3:.2f} kt, in the model {zinc / 1e3:.2f} kt")

moved = table.filter((table["from"] != table["to"]) & (table["tonnage"] > 0))
for a, b, t, g in zip(moved["from"], moved["to"], moved["tonnage"], moved["mean_grade"]):
    print(f"{a:>4} to {b:4} {t / 1e3:5.1f} kt at {g:.2f} % Zn")

Out:

rows are the rocks before: True
columns are the rocks after: True
unchanged: 99.3% of 16.7 Mt
zinc in the table 68.04 kt, in the model 68.04 kt
 HWS to VCL   20.3 kt at 0.06 % Zn
 VCL to HWS   26.4 kt at 0.19 % Zn
 VCL to SUL   10.3 kt at 1.79 % Zn
 SUL to VCL    6.2 kt at 1.25 % Zn
 SUL to FWV   12.8 kt at 0.31 % Zn
 FWV to VCL   43.2 kt at 0.09 % Zn

under 1 % of the tonnage changes rock, at grades far from random: the volcanic cells taken into the sulphides run near 1.8 % Zn, while the sulphide specks given to the footwall hold 0.3 %. the matrices show each row as shares of the rock it starts from, tonnage on the left and zinc on the right. the plot outlines the diagonal and leaves it blank, so the grays show only what moves.

fig, axes = plt.subplots(1, 2, figsize=(11, 4.4), layout="constrained")
for ax, value, title in zip(axes, ["tonnage", "metal"], ["Tonnage", "Zinc"]):
    bt.plot.domain_change(table, value=value, relative=True, fmt="{:.1%}", ax=ax)
    ax.set(title=f"{title}, share of each rock before", xlabel="After cleanup", ylabel="Most likely rock")
axes[1].set_ylabel("")
save(fig, "change")

Tonnage, share of each rock before, Zinc, share of each rock before

Total running time of the script: ( 0 minutes 0.297 seconds)

Download Python source code: example_07_02.py

Download Jupyter notebook: example_07_02.ipynb

Gallery generated by mkdocs-gallery